// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Enumeration;
using System.Runtime.InteropServices;
using System.Security;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace System
{
    public sealed partial class TimeZoneInfo
    {
        private const string TimeZoneFileName = "zone.tab";
        private const string TimeZoneDirectoryEnvironmentVariable = "TZDIR";
        private const string TimeZoneEnvironmentVariable = "TZ";

#if TARGET_WASI || TARGET_BROWSER
        // if TZDIR is set, then the embedded TZ data will be ignored and normal unix behavior will be used
        private static readonly bool UseEmbeddedTzDatabase = string.IsNullOrEmpty(Environment.GetEnvironmentVariable(TimeZoneDirectoryEnvironmentVariable));
#endif

        private static TimeZoneInfo GetLocalTimeZoneCore()
        {
            if (Invariant) return Utc;

            // Without Registry support, create the TimeZoneInfo from a TZ file
            return GetLocalTimeZoneFromTzFile();
        }

        private static byte[] ReadAllBytesFromSeekableNonZeroSizeFile(string path, int maxFileSize)
        {
            using FileStream fs = File.OpenRead(path);
            if (!fs.CanSeek)
            {
                throw new IOException(SR.IO_UnseekableFile);
            }

            if (fs.Length == 0 || fs.Length > maxFileSize)
            {
                throw new IOException(fs.Length == 0 ? SR.IO_InvalidReadLength : SR.IO_FileTooLong);
            }

            byte[] bytes = new byte[fs.Length];
            fs.ReadExactly(bytes, 0, bytes.Length);
            return bytes;
        }

        // Bitmap covering the ASCII range. The bits is set for the characters [a-z], [A-Z], [0-9], '/', '-', and '_'.
        private static ReadOnlySpan<byte> AsciiBitmap => [0x00, 0x00, 0x00, 0x00, 0x00, 0xA8, 0xFF, 0x03, 0xFE, 0xFF, 0xFF, 0x87, 0xFE, 0xFF, 0xFF, 0x07];
        private static bool IdContainsAnyDisallowedChars(string zoneId)
        {
            for (int i = 0; i < zoneId.Length; i++)
            {
                int c = zoneId[i];
                if (c > 0x7F)
                {
                    return true;
                }
                int value = c >> 3;
                if ((AsciiBitmap[value] & (1 << (c & 7))) == 0)
                {
                    return true;
                }
            }
            return false;
        }

        private static TimeZoneInfoResult TryGetTimeZoneFromLocalMachineCore(string id, out TimeZoneInfo? value, out Exception? e)
        {
            Debug.Assert(!Invariant);

            value = null;
            e = null;

            if (Path.IsPathRooted(id) || IdContainsAnyDisallowedChars(id))
            {
                e = new TimeZoneNotFoundException(SR.Format(SR.InvalidTimeZone_InvalidId, id));
                return TimeZoneInfoResult.TimeZoneNotFoundException;
            }

            byte[]? rawData = null;
            string timeZoneDirectory = GetTimeZoneDirectory();
            string timeZoneFilePath = Path.Combine(timeZoneDirectory, id);

#if TARGET_WASI || TARGET_BROWSER
            if (UseEmbeddedTzDatabase)
            {
                if (!TryLoadEmbeddedTzFile(timeZoneFilePath, out rawData))
                {
                    e = new FileNotFoundException(id, "Embedded TZ data not found");
                    return TimeZoneInfoResult.TimeZoneNotFoundException;
                }

                value = GetTimeZoneFromTzData(rawData, id);

                if (value == null)
                {
                    e = new InvalidTimeZoneException(SR.Format(SR.InvalidTimeZone_InvalidFileData, id, id));
                    return TimeZoneInfoResult.InvalidTimeZoneException;
                }

                return TimeZoneInfoResult.Success;
            }
#endif

            try
            {
                rawData = ReadAllBytesFromSeekableNonZeroSizeFile(timeZoneFilePath, maxFileSize: 20 * 1024 * 1024 /* 20 MB */); // timezone files usually less than 1 MB.
            }
            catch (UnauthorizedAccessException ex)
            {
                e = ex;
                return TimeZoneInfoResult.SecurityException;
            }
            catch (FileNotFoundException ex)
            {
                e = ex;
                return TimeZoneInfoResult.TimeZoneNotFoundException;
            }
            catch (DirectoryNotFoundException ex)
            {
                e = ex;
                return TimeZoneInfoResult.TimeZoneNotFoundException;
            }
            catch (Exception ex) when (ex is IOException || ex is OutOfMemoryException)
            {
                e = new InvalidTimeZoneException(SR.Format(SR.InvalidTimeZone_InvalidFileData, id, timeZoneFilePath), ex);
                return TimeZoneInfoResult.InvalidTimeZoneException;
            }

            value = GetTimeZoneFromTzData(rawData, id);

            if (value == null)
            {
                e = new InvalidTimeZoneException(SR.Format(SR.InvalidTimeZone_InvalidFileData, id, timeZoneFilePath));
                return TimeZoneInfoResult.InvalidTimeZoneException;
            }

            return TimeZoneInfoResult.Success;
        }

        /// <summary>
        /// Returns a collection of TimeZone Id values from the time zone file in the timeZoneDirectory.
        /// </summary>
        /// <remarks>
        /// Lines that start with # are comments and are skipped.
        /// </remarks>
        private static IEnumerable<string> GetTimeZoneIds()
        {
            if (Invariant)
            {
                return new string[] { "UTC" };
            }

            try
            {
                var fileName = Path.Combine(GetTimeZoneDirectory(), TimeZoneFileName);
#if TARGET_WASI || TARGET_BROWSER
                if (UseEmbeddedTzDatabase)
                {
                    if (!TryLoadEmbeddedTzFile(fileName, out var rawData))
                    {
                        return Array.Empty<string>();
                    }
                    using var blobReader = new StreamReader(new MemoryStream(rawData), Encoding.UTF8);
                    return ParseTimeZoneIds(blobReader);
                }
#endif
                using var reader = new StreamReader(fileName, Encoding.UTF8);
                return ParseTimeZoneIds(reader);
            }
            catch (IOException) { }
            catch (UnauthorizedAccessException) { }
            return Array.Empty<string>();
        }

        private static List<string> ParseTimeZoneIds(StreamReader reader)
        {
            List<string> timeZoneIds = new List<string>();

            string? zoneTabFileLine;
            while ((zoneTabFileLine = reader.ReadLine()) != null)
            {
                if (!string.IsNullOrEmpty(zoneTabFileLine) && zoneTabFileLine[0] != '#')
                {
                    // the format of the line is "ISO 3166 territory code \t coordinates \t TimeZone Id \t comments"

                    int firstTabIndex = zoneTabFileLine.IndexOf('\t');
                    if (firstTabIndex >= 0)
                    {
                        int secondTabIndex = zoneTabFileLine.IndexOf('\t', firstTabIndex + 1);
                        if (secondTabIndex >= 0)
                        {
                            string timeZoneId;
                            int startIndex = secondTabIndex + 1;
                            int thirdTabIndex = zoneTabFileLine.IndexOf('\t', startIndex);
                            if (thirdTabIndex >= 0)
                            {
                                int length = thirdTabIndex - startIndex;
                                timeZoneId = zoneTabFileLine.Substring(startIndex, length);
                            }
                            else
                            {
                                timeZoneId = zoneTabFileLine.Substring(startIndex);
                            }

                            if (!string.IsNullOrEmpty(timeZoneId))
                            {
                                timeZoneIds.Add(timeZoneId);
                            }
                        }
                    }
                }
            }

            return timeZoneIds;
        }

        private static string? GetTzEnvironmentVariable()
        {
            string? result = Environment.GetEnvironmentVariable(TimeZoneEnvironmentVariable);
            if (!string.IsNullOrEmpty(result))
            {
                if (result[0] == ':')
                {
                    // strip off the ':' prefix
                    result = result.Substring(1);
                }
            }

            return result;
        }

        /// <summary>
        /// Finds the time zone id by using 'readlink' on the path to see if tzFilePath is
        /// a symlink to a file.
        /// </summary>
        private static string? FindTimeZoneIdUsingReadLink(string tzFilePath)
        {
            string? id = null;

            string? symlinkPath = Interop.Sys.ReadLink(tzFilePath);
            if (symlinkPath != null)
            {
                // symlinkPath can be relative path, use Path to get the full absolute path.
                symlinkPath = Path.GetFullPath(symlinkPath, Path.GetDirectoryName(tzFilePath)!);

                string timeZoneDirectory = GetTimeZoneDirectory();
                if (symlinkPath.StartsWith(timeZoneDirectory, StringComparison.Ordinal))
                {
                    id = symlinkPath.Substring(timeZoneDirectory.Length);
                }
            }

            return id;
        }

        private static bool CompareTimeZoneFile(string filePath, byte[] buffer, byte[] rawData)
        {
            try
            {
                // bufferSize == 1 used to avoid unnecessary buffer in FileStream
                using (SafeFileHandle sfh = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
                {
                    long fileLength = RandomAccess.GetLength(sfh);
                    if (fileLength == rawData.Length)
                    {
                        int index = 0;
                        int count = rawData.Length;

                        while (count > 0)
                        {
                            int n = RandomAccess.Read(sfh, buffer.AsSpan(index, count), index);
                            if (n == 0)
                            {
                                ThrowHelper.ThrowEndOfFileException();
                            }

                            if (!buffer.AsSpan(index, n).SequenceEqual(rawData.AsSpan(index, n)))
                            {
                                return false;
                            }

                            count -= n;
                        }

                        return true;
                    }
                }
            }
            catch (IOException) { }
            catch (SecurityException) { }
            catch (UnauthorizedAccessException) { }

            return false;
        }

        /// <summary>
        /// Find the time zone id by searching all the tzfiles for the one that matches rawData
        /// and return its file name.
        /// </summary>
        private static string FindTimeZoneId(byte[] rawData)
        {
            Debug.Assert(!Invariant);

            // default to "Local" if we can't find the right tzfile
            string id = LocalId;
            string timeZoneDirectory = GetTimeZoneDirectory();
            string localtimeFilePath = Path.Combine(timeZoneDirectory, "localtime");
            string posixrulesFilePath = Path.Combine(timeZoneDirectory, "posixrules");
            byte[] buffer = new byte[rawData.Length];

            try
            {
                foreach (string filePath in Directory.EnumerateFiles(timeZoneDirectory, "*", SearchOption.AllDirectories))
                {
                    // skip the localtime and posixrules file, since they won't give us the correct id
                    if (!string.Equals(filePath, localtimeFilePath, StringComparison.OrdinalIgnoreCase)
                        && !string.Equals(filePath, posixrulesFilePath, StringComparison.OrdinalIgnoreCase))
                    {
                        if (CompareTimeZoneFile(filePath, buffer, rawData))
                        {
                            // if all bytes are the same, this must be the right tz file
                            id = filePath;

                            // strip off the root time zone directory
                            if (id.StartsWith(timeZoneDirectory, StringComparison.Ordinal))
                            {
                                id = id.Substring(timeZoneDirectory.Length);
                            }

                            break;
                        }
                    }
                }
            }
            catch (IOException) { }
            catch (SecurityException) { }
            catch (UnauthorizedAccessException) { }

            return id;
        }

        private static bool TryLoadTzFile(string tzFilePath, [NotNullWhen(true)] ref byte[]? rawData, [NotNullWhen(true)] ref string? id)
        {
            Debug.Assert(!Invariant);

            if (File.Exists(tzFilePath))
            {
                try
                {
                    rawData = File.ReadAllBytes(tzFilePath);
                    if (string.IsNullOrEmpty(id))
                    {
                        id = FindTimeZoneIdUsingReadLink(tzFilePath);

                        if (string.IsNullOrEmpty(id))
                        {
                            id = FindTimeZoneId(rawData);
                        }
                    }
                    return true;
                }
                catch (IOException) { }
                catch (SecurityException) { }
                catch (UnauthorizedAccessException) { }
            }
            return false;
        }

#if TARGET_WASI || TARGET_BROWSER
        private static bool TryLoadEmbeddedTzFile(string name, [NotNullWhen(true)] out byte[]? rawData)
        {
            Debug.Assert(!Invariant);

            IntPtr bytes = Interop.Sys.GetTimeZoneData(name, out int length);
            if (bytes == IntPtr.Zero)
            {
                rawData = null;
                return false;
            }

            rawData = new byte[length];
            Marshal.Copy(bytes, rawData, 0, length);
            return true;
        }
#endif

        /// <summary>
        /// Gets the tzfile raw data for the current 'local' time zone using the following rules.
        ///
        /// On iOS / tvOS
        /// 1. Read the TZ environment variable.  If it is set, use it.
        /// 2. Get the default TZ from the device
        /// 3. Use UTC if all else fails.
        ///
        /// On WASI / Browser
        /// 1. if TZDIR is not set, use TZ variable as id to embedded database.
        /// 2. fall back to unix behavior if TZDIR is set.
        ///
        /// On all other platforms
        /// 1. Read the TZ environment variable.  If it is set, use it.
        /// 2. Look for the data in /etc/localtime.
        /// 3. Look for the data in GetTimeZoneDirectory()/localtime.
        /// 4. Use UTC if all else fails.
        /// </summary>
        private static bool TryGetLocalTzFile([NotNullWhen(true)] out byte[]? rawData, [NotNullWhen(true)] out string? id)
        {
            Debug.Assert(!Invariant);

            rawData = null;
            id = null;

            string? tzVariable = GetTzEnvironmentVariable();

            // If the env var is null, on iOS/tvOS, grab the default tz from the device.
            // On all other platforms, use the localtime file.
#pragma warning disable IDE0074 // Use compound assignment
            if (tzVariable == null)
            {
#if TARGET_MACCATALYST || TARGET_IOS || TARGET_TVOS
                tzVariable = Interop.Sys.GetDefaultTimeZone();
#elif TARGET_WASI || TARGET_BROWSER
                if (UseEmbeddedTzDatabase)
                {
                    return false; // use UTC
                }
#else
                return
                    TryLoadTzFile("/etc/localtime", ref rawData, ref id) ||
                    TryLoadTzFile(Path.Combine(GetTimeZoneDirectory(), "localtime"), ref rawData, ref id);
#endif
            }
#pragma warning restore IDE0074

            // If it's empty, use UTC (TryGetLocalTzFile() should return false).
            if (string.IsNullOrEmpty(tzVariable))
            {
                return false;
            }

            // Otherwise, use the path from the env var.  If it's not absolute, make it relative
            // to the system timezone directory
            string tzFilePath;
            if (tzVariable[0] != '/')
            {
                id = tzVariable;
                tzFilePath = Path.Combine(GetTimeZoneDirectory(), tzVariable);
            }
            else
            {
                tzFilePath = tzVariable;
            }

#if TARGET_WASI || TARGET_BROWSER
            if (UseEmbeddedTzDatabase)
            {
                // embedded database only supports relative paths
                if (tzVariable[0] == '/')
                {
                    return false;
                }
                if (!TryLoadEmbeddedTzFile(tzFilePath, out rawData))
                {
                    return false;
                }
                id = tzVariable;
                return true;
            }
#endif

            return TryLoadTzFile(tzFilePath, ref rawData, ref id);
        }

        /// <summary>
        /// Helper function used by 'GetLocalTimeZone()' - this function wraps the call
        /// for loading time zone data from computers without Registry support.
        ///
        /// The TryGetLocalTzFile() call returns a Byte[] containing the compiled tzfile.
        /// </summary>
        private static TimeZoneInfo GetLocalTimeZoneFromTzFile()
        {
            Debug.Assert(!Invariant);

            byte[]? rawData;
            string? id;
            if (TryGetLocalTzFile(out rawData, out id))
            {
                TimeZoneInfo? result = GetTimeZoneFromTzData(rawData, id);
                if (result != null)
                {
                    return result;
                }
            }

            // if we can't find a local time zone, return UTC
            return Utc;
        }

        private static string GetTimeZoneDirectory()
        {
            string? tzDirectory = Environment.GetEnvironmentVariable(TimeZoneDirectoryEnvironmentVariable);

            if (string.IsNullOrEmpty(tzDirectory))
            {
                tzDirectory = DefaultTimeZoneDirectory;
            }
            else if (!tzDirectory.EndsWith(Path.DirectorySeparatorChar))
            {
                tzDirectory += PathInternal.DirectorySeparatorCharAsString;
            }

            return tzDirectory;
        }
    }
}
